Skip to content

Guard a read model with the authorization of the queries that read it - #29

Merged
woksin merged 2 commits into
mainfrom
fix/query-authorization-per-read-model
Aug 12, 2026
Merged

Guard a read model with the authorization of the queries that read it#29
woksin merged 2 commits into
mainfrom
fix/query-authorization-per-read-model

Conversation

@woksin

@woksin woksin commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Changed

  • A read model that no declared query returns now renders as requiring an authenticated caller, and reports it as a diagnostic, rather than being left open to everyone. A slice that declares no query at all is unchanged and still renders as [AllowAnonymous].

Security

  • A read model is now guarded by the authorization declared by the queries that read it, instead of by every query in its slice. A slice declaring several read models rendered each of them with the union of all their queries' authorization, so a single unguarded query on one read model rendered the others as [AllowAnonymous] — the rendered application was more permissive than the document it came from.

woksin added 2 commits August 12, 2026 10:39
A slice declares as many read models as its behavior needs, and a query
names the one it reads with its return type. The union guarding a read
model was drawn from every query in the slice regardless, so a query on
one read model decided the authorization of all the others.

That fails in the permissive direction. AuthorizationRenderer collapses
a union to AllowAnonymous as soon as one member is unguarded - correct
for queries that genuinely read the same model - so one unguarded query
on an unrelated read model published a guarded one to everyone.

Attribute each query to the read model its return type names, and take
the union over those alone. A read model that no declared query returns
falls back to requiring an authenticated caller and reports it, rather
than being left open; a slice declaring no query at all is unchanged.
Covers the multi-read-model slice end to end through the renderer - the
guarded read model stays guarded beside another one's unguarded query -
and the attribution rules on their own, including that the collapse to
anonymous still holds for two queries reading the same read model.
@woksin woksin added the minor label Aug 12, 2026
@woksin

woksin commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

The attribution key

A query names the read model it reads with its return type, so Identifiers.ToPascalCase(query.ReturnType.Name) against the projection's read model name is the key. IsCollection is ignored — InvoiceListReadModel[] reads InvoiceListReadModel just as a single instance does. The invoicing sample's slice StateView InvoiceDashboard is exactly this shape: one projection building InvoiceSummaryReadModel, plus a GetOverdueInvoices => OverdueInvoicesReadModel[] query for a read model the slice never projects.

Why the unqueried read model does not simply render anonymous

Filtering alone would have introduced a new permissive path: a read model whose slice declares queries only for its siblings would go from a (wrong, but closed) foreign guard straight to [AllowAnonymous]. So the no-own-queries case falls back to Authorize plus a diagnostic — the same fallback AuthorizationRenderer already uses for an undeclared policy, a code policy and a claim policy. The two silences are deliberately distinguished: a slice declaring no query at all says nothing about reading anything and keeps its existing [AllowAnonymous]; a slice that states read authorization for its other read models and none for this one is a document whose silence must not be read as a grant.

The pre-existing "a read model with no declared queries anywhere still gets an unguarded all/by-id pair the document never asked for" is untouched and out of scope here.

Not this PR

#20 (authorize A B rendering as Roles(A, B)) is independent and untouched, as is its deliberately-wrong pinning spec and_one_of_several_declarations_is_missing. That collapse is correct within one read model — and_one_of_its_own_queries_is_unguarded pins it staying that way.

Verification

  • dotnet build -c Debug and -c Release — 6 projects, 0 errors, 0 warnings.
  • dotnet test — 367 passed, 0 failed (Contracts 125, Stage 19, Rendering.Cratis 223; 10 of those new).
  • dotnet pack -c Release — exit 0, 0 warnings, package validation clean against the 2.0.0 baseline. ReadModelAuthorization is additive public API, which is the second reason for minor beside the rendering-behavior change.
  • Red proof: reverting only the call site in StateViewSliceRenderer to the whole-slice union fails when_another_read_model_has_an_unguarded_query on both assertions — [Roles("Accountant")] not found, [AllowAnonymous] present — while should_render_the_first_projections_read_model still passes, so the failure is specific to authorization rather than the fixture.

@woksin
woksin merged commit 260a69d into main Aug 12, 2026
1 check passed
@woksin
woksin deleted the fix/query-authorization-per-read-model branch August 12, 2026 08:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant